#!/usr/bin/perl

use strict;
use DBI;
use Sys::Syslog;
use Sys::Statistics::Linux::Processes;
use Getopt::Std;

my %config = (
		"detach",			"true",
		"facility",			"news",
		"pidfile",			"/var/run/nnrpguard.pid",
		"scan",				30,
		"input_file", 			"/var/log/news/connections.log",
		"output_file",	 		"/etc/hosts.deny",
		"database",   			"/var/spool/news/postfilter/connections.dbl",
		"expire",			"86400",
		"max_connections", 		300,
		"error_max_connections", 	"Too many total connections",
		"max_duration", 		5400,
		"error_max_duration",		"Too long total connections", 		
		"max_errors",			250,
		"error_max_errors",		"Too many nnrpd errors",		
		"max_failures",			50,
		"error_max_failures",		"Too many attempts to flood the server"
	     );

my %options = ();
getopts("i:o:s:t:d:e:c:p:z:w:q:hDZ",\%options);
$config{'input_file'}                         =  $options{i} if (defined $options{i});
$config{'output_file'}                        =  $options{o} if (defined $options{o});
$config{'facility'}                           =  $options{s} if (defined $options{s});
$config{'scan'}                               =  $options{t} if (defined $options{t});
$config{'database'}                           =  $options{d} if (defined $options{d});
$config{'expire'}                             =  $options{e} if (defined $options{e});
$config{'max_connections'}                    =  $options{c} if (defined $options{c});
$config{'max_duration'}                       =  $options{p} if (defined $options{p});
$config{'max_errors'}                         =  $options{z} if (defined $options{z});
$config{'max_failures'}	                      =  $options{w} if (defined $options{w});
$config{'pidfile'}                            =  $options{q} if (defined $options{q});
$config{'detach'}                             =  "false"     if (defined $options{D});

if (defined $options{Z})
{
        &kill_nnrpguard();
        exit(0);
}

&init_checks();

if (defined $options{h})
{
        print <<EOT;
nnrpguard v. 0.7.7 Copyright 2009 by Paolo Amoroso <freedom\@aioe.org> (BSD License)

Syntax:
        nnrpguard [ options ]
        nnrpguard -h

Options:
        -i <file>        : xinetd log file used to fetch data
                           Default: /var/log/news/connections.log
	-o <file>	 : TCPD file where banned IP are written
			   Default: /etc/hosts.deny
	-q <file>	 : Pidfile to use
			   Default: /var/run/nnrpguard.pid
	-s <facility>	 : Syslog facility to use
			   Default: news
	-t <seconds>	 : Scan xinetd logfile every <seconds>
			   Default:   30 seconds
	-d <file>	 : SQLite database file
			   Default: /etc/news/postfilter/connections.dbl
	-e <seconds>	 : Remove old records from DB after <seconds> seconds
			   Default: 86400 seconds, one day
	-c <connections> : Ban each IP address that exceeds <connections> in -e <seconds>
			   Default:   300
	-p <seconds>	 : Ban each IP that stays connected in total more than <seconds> in -e <seconds>
			   Default:  5400
	-z <errors>	 : Ban each IP when nnrpd exits with non-zero valure more than <errors> times in -e <seconds>
			   Default:   250
	-w <errors>	 : Ban each IP when xinetd refuses to start new connections more that <errors> times in -e <seconds>
			   Default:    50

	-D		 : Do *not* detach from the parent process

EOT
	exit(0);
}


my $data = {};

if (!-e $config{'database'})
{
	print "Initializying database\n";
	&init_database($config{'database'});
} 

my $dbh = DBI->connect("dbi:SQLite:dbname=$config{'database'}","","") or die "Couldn't connect to database: " . DBI->errstr;

my $propid;

if ( $config{'detach'} eq "true" )
{
        $propid = fork();
} else
{
        $propid = 0;
}

if (not defined $propid)
{
        die "Unable to fork\n";
} elsif ($propid == 0)
{
	while(1)
	{
		my $st1 = $dbh->prepare("INSERT INTO nnrpd ( time, ip, status, pid, duration ) VALUES ( ?, ?, ?, ?, ? )" );

		my $FILE;

		open $FILE, "$config{'input_file'}";
		if (!$FILE)
		{
			die "Unable to read $config{'input_file'}";
		}
		my @lines = <$FILE>;
		close $FILE;

		system( "cat /dev/null >$config{'input_file'}" );

		my %faildata;
		my %scandata;
		$scandata{'start_time'} = time();

		foreach (@lines)
		{
			$scandata{'total'}++;
			if ( $_  =~ /START/ )
			{
				$_  =~ /START\:\ (.+)\ pid=([0-9]+)\ from=(.+)\n/;
				$data->{$2}->{'service'} = $1;
				$data->{$2}->{'pid'}	 = $2;
				$data->{$2}->{'ip'}	 = $3;  
				$scandata{'START'}++;
			}	

			if ( $_  =~ /EXIT/ )
			{
		 		$_  =~ /EXIT\:\ (.+)\ status=([0-9]+)\ pid=([0-9]+) duration=([0-9]+)/;

				if ( $data->{$3}->{'pid'} != 0 )
				{
					my $time = time();
					my $success = $st1->execute( $time, $data->{$3}->{'ip'}, $2, $3, $4 ); 
					if (!$success)
					{
						die "Unable to populate database";
					}
					if ( $config{'verbose'} eq "true" )
					{
						&log( "debug", "Processing $data->{$3}->{'ip'}, $4 seconds, exit status $2" );
					}
					delete $data->{$3};
				}
				else
				{
					$scandata{'badexit'}++;
				}
				$scandata{'EXIT'}++;
			}	

			if ( $_  =~ /FAIL/ )
			{
				$_ =~ /FAIL\:\ (.+)\ (.+)\ from=(.+)\n/;
				$faildata{$3} = 0 if ( $faildata{$3} eq "" );
				$faildata{$3}++;		 
				$scandata{'FAIL'}++;
			}
		}
		$st1->finish();
		my $st2 = $dbh->prepare("INSERT INTO nnrpd ( time, ip, errors ) VALUES ( ?, ?, ? )" );

		$scandata{'fail_ip'} = 0;

		foreach ( keys %faildata ) 
		{
			my $number = $faildata{$_};
			my $time = time();
        		my $success = $st2->execute( $time, $_, $number ) if ( length($_) > 5 );
        		if (!$success)
        		{
				die "Unable to populate database";
        		}
			$scandata{'fail_ip'}++;
		}

		%faildata = undef;

		$st2->finish();

		my $st3 = $dbh->prepare( "select ip, count(ip) as connections, sum(duration) as duration, sum(status!=0) as errors, sum(errors) as failures from nnrpd where time > ? group by ip" );
		my $st4 = $dbh->prepare( "delete from nnrpd where ip = ?" );

		my $expire = time() - $config{'expire'};
		$st3->execute($expire);

		my $BAN;
		open $BAN, ">>$config{'output_file'}";
		if (!$BAN)
		{
			die "Unable to open output file";
		}	

		$scandata{'banned'} = 0;

		while (my @data = $st3->fetchrow_array()) 
		{
			$scandata{'processed'}++;
			my ($ip, $connections, $duration, $errors, $failures ) = "0";
			$ip 			= $data[0];
        		$connections 		= $data[1];
			$duration 		= $data[2];
			$errors 		= $data[3];
			$failures		= $data[4];
			my $time = time();

			if ( $connections > $config{'max_connections'} )
			{
				print $BAN "nnrpd:$ip: twist echo -e \"502 $config{'error_max_connections'}\\r\\n\" # $time\n" if ( $ip ne "" );
				$st4->execute($ip);
				$scandata{'max_conn'}++;
			}

        		if ( $duration > $config{'max_duration'} )
        		{
                		print $BAN "nnrpd:$ip: twist echo -e \"502 $config{'error_max_duration'}\\r\\n\" # $time\n" if ( $ip ne "" );
                		$st4->execute($ip);
				$scandata{'max_duration'}++;
        		}

        		if ( $errors > $config{'max_errors'} )
        		{
                		print $BAN "nnrpd:$ip: twist echo -e \"502 $config{'error_max_errors'}\\r\\n\" # $time\n" if ( $ip ne "" );
                		$st4->execute($ip);
				$scandata{'max_errors'}++;
        		}

        		if ( $failures > $config{'max_failures'} )
        		{
                		print $BAN "nnrpd:$ip: twist echo -e \"502 $config{'error_max_failures'}\\r\\n\" # $time\n" if ( $ip ne "" );
                		$st4->execute($ip);
				$scandata{'max_failures'}++;
        		}


		}
		close $BAN;

		$st3->finish();
		$st4->finish();
	
		my $st5 = $dbh->prepare( "delete from nnrpd where time < ?" );	
		$expire = time() - $config{'expire'};		
		$st5->execute($expire);
		$st5->finish();

		$scandata{'duration'} = time() - $scandata{'start_time'};
		$scandata{'total_conn'} = $scandata{'START'} + $scandata{'FAIL'} + $scandata{'EXIT'};

		$scandata{'START'} = 0 if ( $scandata{'START'} eq "" );
		$scandata{'EXIT'} = 0 if ( $scandata{'EXIT'} eq "" );
		$scandata{'FAIL'} = 0 if ( $scandata{'FAIL'} eq "" );
		$scandata{'total'} = 0 if ( $scandata{'total'} eq "" );
		$scandata{'processed'} = 0 if ( $scandata{'processed'} eq "" );
		$scandata{'max_conn'} = 0 if ( $scandata{'max_conn'} eq "" );
		$scandata{'max_duration'} = 0 if ( $scandata{'max_duration'} eq "" );
		$scandata{'max_errors'} = 0 if ( $scandata{'max_errors'} eq "" );		
		$scandata{'max_failures'} = 0 if ( $scandata{'max_failures'} eq "" );

		$scandata{'banned'} =  $scandata{'max_conn'} +  $scandata{'max_duration'} +  $scandata{'max_errors'} +  $scandata{'max_failures'};

		&log( "notice", 
			"Found $scandata{'total_conn'} new records, $scandata{'START'} new, $scandata{'EXIT'} closed and $scandata{'FAIL'} blocked connections ($scandata{'fail_ip'} IPs)",
			"$scandata{'processed'} IPs were processed, $scandata{'total'} lines, $scandata{'duration'} seconds were spent",
			"Banned IP addresses: total $scandata{'banned'}, too many connections  $scandata{'max_conn'}, errors $scandata{'max_errors'}, duration  $scandata{'max_duration'}, failures $scandata{'max_failures'}"  );

		my $stop_time = $config{'scan'} - $scandata{'duration'};
		$stop_time = 0 if ( $stop_time <= 0 );
		sleep $stop_time;
	}
}

sub init_database($)
{
	my $dbf = $_[0];
	my $dbh = DBI->connect("dbi:SQLite:dbname=$dbf","","") or die "Couldn't connect to database: " . DBI->errstr;
	$dbh->do("CREATE TABLE nnrpd ( ID LONG AUTO_INCREMENT PRIMARY KEY, time LONG, ip CHAR(24), status INT, pid INT, duration INT, errors INT)") or die "Couldn't prepare table creation due " . DBI->errstr;
	$dbh->disconnect();
}


sub log()    # log( severity, string1, string2.... );
{
        my @strings = @_;
        openlog ( 'nnrpguard', 'cons,pid', $config{'facility'} );

        my $severity = shift(@strings);

        foreach ( @strings )
        {
                syslog( $severity, $_ );
        }

        closelog();
}


sub kill_nnrpguard()
{
        my $tf = $config{'pidfile'};

        my $GPP;
        open $GPP, "$tf";       
        my $pid = <$GPP>;       
        $pid =~ s/\n//;
        if ( $pid eq "" )
        {
                my $text = "No other guard instances are running";
                print "$text\n" if ( $config{'detach'} ne "true" );
                &log( "err", $text);
                return 0;
        }
        else
        {
                close $GPP;
                my $status =  `kill -9 $pid`;
                $status =  `rm $tf`;    
        }
        return 0;
}


sub init_checks()
{
        my $name = `whoami`;
        $name =~ s/\n//;

        if ( $name ne "root" )
        {
                my $text = "nnrpdlimit must be executed by root not by $name";
                print "$text\n" if ( $config{'detach'} ne "true" );
                &log( "crit", $text);
                exit 5;
        }

        my $tf = $config{'pidfile'};

        my $GPP;
        open $GPP, "$tf";

        if ($GPP)
        {
                my $pid = <$GPP>;
                close $GPP;
                $pid =~ s/\n//;
                if ( $pid ne "" )
                {
                        my $text = "Found an existing pidfile ($config{'pidfile'}) with $pid pid";
                        print "$text\n" if ( $config{'detach'} ne "true" );
                        &log( "err", $text);            

                        my $lxs = Sys::Statistics::Linux::Processes->new([$pid]);
                        $lxs->init;
                        my $stats = {};
                        $stats = $lxs->get();

                        if ( $stats->{$pid}->{'cmdline'} =~ /nnrpguard/ )
                        {
                                my $text = "Found another working instance (pid $pid) of nnrpdlimit";
                                print "$text\n" if ( $config{'detach'} ne "true" );
                                &log( "crit", $text);
                                exit 5;
                        }
                }
        }


        my $cpid = $$ + 5;

        my $PID;
        open $PID, ">$tf";

        if (!$PID)
        {
                my $text = "Unable to write pidfile at $tf";
                print "$text\n" if ( $config{'detach'} ne "true" );
                &log( "crit", $text);
                exit 5;
        }       

        print $PID "$cpid\n";
        close $PID;
}
